Skip to content

refactor(i18n): migrate to i18next framework#331

Open
cosarah wants to merge 18 commits intomainfrom
feat/i18next
Open

refactor(i18n): migrate to i18next framework#331
cosarah wants to merge 18 commits intomainfrom
feat/i18next

Conversation

@cosarah
Copy link
Copy Markdown
Collaborator

@cosarah cosarah commented Mar 30, 2026

Summary

Migrate the hand-rolled i18n system to i18next + react-i18next. Adding a new language now only requires dropping a JSON file in lib/i18n/locales/ — zero changes to existing code.

Related Issues

Closes #327

Changes

  • Add i18next, react-i18next, i18next-resources-to-backend dependencies
  • Replace 5 TS translation modules (chat, common, generation, settings, stage) with 2 JSON locale files (zh-CN.json, en-US.json)
  • Rewrite lib/i18n/index.ts as a thin wrapper around i18n.t()
  • Rewrite use-i18n hook to delegate to useTranslation(); external API (locale, setLocale, t) unchanged — all 59+ consumer files need no changes
  • Auto-discover locale files via dynamic import() — bundler scans locales/ at build time
  • Defer language detection to useEffect after hydration (fixes SSR mismatch)
  • Remove hardcoded locale validation; rely on i18next's built-in fallback mechanism
  • Use i18next interpolation for greeting (greetingWithName / greetingDefault) to support natural phrasing across languages
  • Widen Locale type from 'zh-CN' | 'en-US' to string for extensibility

Type of Change

  • Bug fix (non-breaking change that fixes an issue)
  • New feature (non-breaking change that adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update
  • Refactoring (no functional changes)
  • CI/CD or build changes

Verification

Steps to reproduce / test

  1. Start dev server (pnpm dev)
  2. Verify all UI text renders correctly in both Chinese and English
  3. Toggle language via the header language selector — all text should switch
  4. Check browser with English locale — should auto-detect and switch to English on first visit
  5. Verify greeting shows "Hi there" (no nickname) / "Hi, Alice" (with nickname) in English mode

What you personally verified

  • Full page navigation in both languages
  • Language persistence across page refresh (localStorage)
  • SSR hydration — no mismatch warning in console
  • Browser language auto-detection on first visit

Evidence

  • CI passes (pnpm format && pnpm lint --fix && npx tsc --noEmit)
  • Manually tested locally
  • Screenshots / recordings attached (if UI changes)

Checklist

  • My code follows the project's coding style
  • I have performed a self-review of my code
  • I have added/updated documentation as needed
  • My changes do not introduce new warnings

cosarah and others added 12 commits March 30, 2026 15:28
Replace hand-rolled i18n with i18next + react-i18next so that adding a
new language only requires dropping a JSON file in lib/i18n/locales/.

- Add i18next, react-i18next, i18next-browser-languagedetector deps
- Generate zh-CN.json / en-US.json from existing TS translation modules
- Rewrite lib/i18n/index.ts as a thin wrapper around i18n.t()
- Rewrite use-i18n hook to delegate to useTranslation(); external API
  (locale, setLocale, t) is unchanged so consumers need no changes
- SSR-safe: LanguageDetector only loaded on client side

Closes #327

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace string concatenation (greeting + displayName) with two
i18next keys: greetingWithName (with {{name}} interpolation) and
greetingDefault (standalone, no name).

This lets each locale choose natural phrasing independently:
- zh-CN: "嗨,同学" / "嗨,Alice"
- en-US: "Hi there" / "Hi, Alice"
- Future locales can avoid gender issues by choosing genderless defaults

Also widen the t() type signature to accept interpolation options.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace hardcoded zh-CN/en-US imports with i18next-resources-to-backend
and dynamic import(`./locales/${language}.json`). Bundler scans the
locales/ directory at build time, so adding a new language now requires
only dropping a JSON file — zero changes to existing code.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
LanguageDetector ran during i18next init(), detecting browser language
before React hydrated — server rendered zh-CN while client switched to
en-US immediately, causing a hydration mismatch.

Fix: remove i18next-browser-languagedetector; init with a fixed lng
(zh-CN) so server and client agree on the first render. Language
detection is now done in I18nProvider's useEffect after hydration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace manual locale validation and startsWith('zh') prefix matching
with i18next's built-in fallback mechanism. Now changeLanguage() is
called with navigator.language directly — if the exact locale has no
JSON file, i18next automatically falls back to fallbackLng.

Also widen Locale type from union to string so adding new languages
doesn't require modifying types.ts.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Add lib/i18n/course-languages.ts with curated language list
  (zh-CN, zh-TW, en-US, ja, ko, fr, de, es, pt, ru, ar) including
  native labels and English prompt names
- Course language defaults to UI locale on first visit; once user
  explicitly picks a language, that choice persists across sessions
- Replace toggle button with dropdown selector showing native labels
- Widen language types from 'zh-CN'|'en-US' to string throughout
- Fix hardcoded language ternaries in LLM prompt injection:
  - prompt-builder.ts: use getCourseLanguagePromptName()
  - classroom-generation.ts: remove normalizeLanguage() that forced
    all non-English to zh-CN
  - PBL system prompt, agent templates, generate-pbl: append language
    instruction for non-zh/en languages
  - quiz-grade API: add language suffix for grading feedback

Closes #327

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
quiz-view was passing the UI locale to the grading API, causing AI
feedback to follow the student's answer language instead of the course
language. Now reads stage.language from the store.

Also strengthen the grading prompt: explicitly instruct the LLM to
write comments in the course language regardless of the student's
input language.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reverts f7bd6bd and 5bb5133 — these are independent features that
should go into a separate branch/PR.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
These files are replaced by lib/i18n/locales/*.json and are no longer
imported anywhere in the codebase.

Removed: chat.ts, common.ts, generation.ts, settings.ts, stage.ts

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cosarah cosarah marked this pull request as draft April 3, 2026 06:48
cosarah and others added 4 commits April 3, 2026 15:09
…e registry

Add lib/i18n/locales.ts as a single source of truth for supported languages.
Language selectors in header and homepage now render dynamically from this
registry. Also adds supportedLngs to i18next config so unsupported languages
fall back correctly. Adding a new language now only requires a JSON file and
one line in the registry.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Resolve modify/delete conflict on lib/i18n/generation.ts — this file was
removed in the i18next branch (translations migrated to JSON locale files)
and modified on main. All new keys from main are already present in the
JSON locales, so the TS file is no longer needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add classroom.rename, classroom.renamePlaceholder, and
classroom.renameFailed that were added to generation.ts on main
but missing from the JSON locale files.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…cale resolution

- Extract shared <LanguageSwitcher /> component from page.tsx and header.tsx
- Derive Locale type from supportedLocales registry (as const satisfies)
- Add resolveLocale() to match browser language prefixes (e.g. 'en' → 'en-US')
- Remove config.ts changes (nonExplicitSupportedLngs + inline resources) that
  broke translation loading when combined with resourcesToBackend

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cosarah
Copy link
Copy Markdown
Collaborator Author

cosarah commented Apr 3, 2026

What this PR does

Migrates the hand-rolled i18n system to i18next + react-i18next. The external API (useI18n, t(), setLocale) is unchanged — all 59+ consumer files need zero modifications.

Key changes

  • 5 TS translation modules → 2 JSON locale files (zh-CN.json, en-US.json) loaded via dynamic import() at build time
  • Central locale registry (lib/i18n/locales.ts): single source of truth for supported languages. Adding a new language requires only a JSON file + one line in the registry
  • Shared <LanguageSwitcher /> component: replaces duplicated language selector logic in page.tsx and header.tsx
  • Locale type safety: derived from the registry via as const satisfies, so typos are caught at compile time
  • Browser language matching: resolveLocale() maps short codes (e.g. en, zh) to supported locales via prefix match, with localStorage persistence after first manual selection
  • SSR hydration fix: language detection deferred to useEffect to avoid server/client mismatch
  • i18next interpolation: greeting uses {{name}} placeholder instead of string concatenation, enabling natural phrasing per locale

@cosarah cosarah marked this pull request as ready for review April 3, 2026 08:41
@cosarah cosarah marked this pull request as draft April 3, 2026 09:35
cosarah and others added 2 commits April 3, 2026 17:57
…replace()

- Convert all {var} placeholders in locale JSONs to {{var}} (i18next default)
- Replace .replace('{var}', value) calls with t(key, {var: value}) in all
  consuming components: lecture-notes-view, pbl-renderer, use-pbl-chat,
  generation-preview, whiteboard-history, agent-settings
- Widen t() type signature in handleIssueComplete to accept interpolation options

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Remove greetingDefault key; greeting always uses greetingWithName
  with displayName (falls back to profile.defaultNickname)
- Change en-US defaultNickname from "Student" to "Learner"
- Add TRANSLATION_GUIDE.md documenting how to add languages and
  explaining keys with non-obvious UX design intent

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cosarah
Copy link
Copy Markdown
Collaborator Author

cosarah commented Apr 3, 2026

Updates since last comment

Fix: interpolation placeholders (93f5e42)

The JSON locale files used single-brace {var} placeholders, but i18next defaults to double-brace {{var}}. All ~20 interpolation keys were silently broken (outputting raw {name} instead of the actual value).

  • Converted all {var}{{var}} in both JSON files
  • Replaced manual .replace('{var}', value) calls in 6 consuming components with native i18next interpolation t(key, { var: value })

Improvement: unify greeting with default nickname (f365c75)

Previously greetingDefault ("Hi there") and profile.defaultNickname ("Student") were independent — the greeting didn't hint that it's a clickable entry point for setting your nickname.

  • Removed greetingDefault; greeting now always uses greetingWithName with displayName (falls back to profile.defaultNickname)
  • Changed en-US defaultNickname from "Student" to "Learner" (warmer, more aligned with the self-directed learning positioning)
  • Flow: user sees "Hi, Learner" → clicks → nickname editor opens with "Learner" pre-filled → user replaces with their name

New: translation guide (f365c75)

Added lib/i18n/TRANSLATION_GUIDE.md for language contributors, covering:

  • Step-by-step instructions for adding a new language
  • Interpolation syntax ({{var}})
  • Table of keys with non-obvious UX design intent (e.g. why greetingWithName doubles as a CTA, what tone defaultNickname should have)

@cosarah cosarah marked this pull request as ready for review April 3, 2026 10:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Feature]: Migrate i18n to i18next framework

1 participant